前兩日介紹了一下 RISC-V 的整數基本指令集架構,我們發現到設計者的巧思與其中的簡潔之美;跳脫欣賞的角度,我們也需要這些知識來幫助 rvgc 函式庫的設計。今天我們要將 rvgc 函式庫好好地強化,讓既有的 as 仍舊能夠順利完成同時,也為未來的 objdump 工具程式鋪路。
顯然,最主要的 rvgc 函式庫功能應該能支援組合語言指令與二進位編碼之間的轉換。給定規格書上的內容,或是如昨日基本整數指令集的介紹,我們必然可以將敘述句轉換為機器的編碼;反之,也能夠辨認合理的編碼,轉譯回來成為人類可讀的指令,或是判定所讀到的指令不成立。前者就是我們有一個空殼的 InstToBin
函式,後者就暫定名為 BinToInst
。
很抱歉之前的名稱
Cmd2Hex
取得非常不講究:Cmd 可以是指令的意思,但是組語檔案內的指令不該被稱為 command;機器語言的編碼也可以有各種格式,不一定非得是 16 進位不可。這裡就改掉這個名子,比較名符其實。
照著昨日的內容完成 InstToBin
應該不至於太困難。但可能會有的後續挑戰是,我們還無法處理規格中支援的十來個虛擬指令,比方說沒有功能的 nop 指令、讀取變數位址的 la 指令等等。其中有些可以輕鬆解決,有些卻會牽涉到重定位(relocate)和後續的連結過程。對這些未來的工作項目保持警覺,我們就先來完成基本整數指令集的部份吧。
一個真正的 InstToBin
函數,應該要根據第一個字串,也就是指令識別字(mnemonic),決定該用什麼格式去分析。所以首先要有一組指令型態的常數定義:
type RV_INST_TYPE uint32
const (
RV_INST_NONE RV_INST_TYPE = 0
RV_INST_R_TYPE RV_INST_TYPE = 1
RV_INST_I_TYPE RV_INST_TYPE = 2
RV_INST_S_TYPE RV_INST_TYPE = 3
RV_INST_B_TYPE RV_INST_TYPE = 4
RV_INST_U_TYPE RV_INST_TYPE = 5
RV_INST_J_TYPE RV_INST_TYPE = 6
)
然後,為了能夠在第一時間判別一行指令應該屬於哪一種型態,筆者決定開一個 map[string]RV_INST_TYPE
的變數當作對照表使用,由於內容包含這些指令,這裡就簡單節錄:
var mnem2type = map[string]RV_INST_TYPE{
"add": RV_INST_R_TYPE,
"sub": RV_INST_R_TYPE,
"sll": RV_INST_R_TYPE,
"slt": RV_INST_R_TYPE,
...
}
從昨日的介紹可知,其實 RISC-V 讓許多指令共用 opcode,因此我們可以定義一個常數結構代表 opcode:
type RV_OPCODE_TYPE uint32
const (
RV_OPCODE_OP RV_OPCODE_TYPE = 0x33
RV_OPCODE_OP_32 RV_OPCODE_TYPE = 0x3b
RV_OPCODE_LOAD RV_OPCODE_TYPE = 0x03
RV_OPCODE_STORE RV_OPCODE_TYPE = 0x23
RV_OPCODE_OP_IMM RV_OPCODE_TYPE = 0x13
RV_OPCODE_OP_IMM_32 RV_OPCODE_TYPE = 0x1b
RV_OPCODE_BRANCH RV_OPCODE_TYPE = 0x63
RV_OPCODE_JAL RV_OPCODE_TYPE = 0x6f
RV_OPCODE_JALR RV_OPCODE_TYPE = 0x67
RV_OPCODE_LUI RV_OPCODE_TYPE = 0x37
RV_OPCODE_AUIPC RV_OPCODE_TYPE = 0x17
RV_OPCODE_SYSTEM RV_OPCODE_TYPE = 0x73
)
實際上轉譯成指令時,我們只會取其中的最後 7 個 bit。從命名方式不難看出,這和前面的指令多半都有直接對應的關係。這些名稱都是直接取自規格書中的命名方法;關於 opcode,有使用的部份可以形成一個表格(最後兩個 bit 都是 1):
+-------\-------+---------+---------+---------+---------+---------+---------+---------+
| [6:5] \ [4:2] | 000 | 001 | 010 | 011 | 100 | 101 | 110 |
+-------\-------+---------+---------+---------+---------+---------+---------+---------+
| 00 | LOAD | | | | OP-IMM | AUIPC |OP-IMM-32|
+---------------+---------+---------+---------+---------+---------+---------+---------+
| 01 | STORE | | | | OP | LUI | OP-32 |
+---------------+---------+---------+---------+---------+---------+---------+---------+
| 10 | | | | | | | |
+---------------+---------+---------+---------+---------+---------+---------+---------+
| 11 | BRANCH | JALR | | JAL | SYSTEM | | |
+---------------+---------+---------+---------+---------+---------+---------+---------+
同樣的,也設置一個 map[string]RV_OPCODE_TYPE
型態的變數當作對照表使用,這裡就不再列出。比較會搞混的是在 64I 指令集中擴充出來的那些 w 結尾的指令,只要從 opcode 去認識它們就可以了。
這一段和昨日的文章對照應該會更容易看懂。
首先是通用部份,筆者將之前在 as.go
中的內容稍微修改了一下。還記得嗎?在 Run
函數逐行解讀輸入的組語檔案時,會經過字串處理程式如下:
sa := strings.Split(strings.TrimSpace(string(line)), " ")
這一行的原意是,這一整行的組語碼可能因為風格之類的因素而有前後的多餘空白字元(包含排版),所以 TrimSpace
函數先用來消除頭尾的空白字元之後,再透過 Split
函數以空白當作分隔符分割指令。但光光是要支援基本整數指令集的此時,這個方法就已經會有限制,比方像 I-type 或是 S-type 的指令型態如下(節錄自 Linux 核心的反組譯檔):
ld a5,8(s1)
addi a5,a5,1
sd a5,8(s1)
顯然,有些組語的寫作風格會用逗點分隔運算子,其中可能一個空白都沒有;又,在讀取或是存回的指令裡面,都有運算子的定址模式含有括弧,這會使得語法解析變困難。因此筆者這裡決定將逗號或括弧全部視作空白字元,並將上列程式碼提出到 Run
函式之外,名為 preProcessLine
:
func preProcessLine (line string) []string {
rePunc := regexp.MustCompile(`,()`)
reSpace := regexp.MustCompile(`[[:space:]]+`)
line = rePunc.ReplaceAllString(line, " ")
line = reSpace.ReplaceAllString(line, " ")
sa := strings.Split(strings.TrimSpace(string(line)), " ")
return sa
}
這麼一來,前述組語指令就應該可以變為
ld a5 8 s1
addi a5 a5 1
sd a5 8 s1
可讀性是降低了,但反正是中間產物,所以無所謂。
在 InstToBin
之中,一開始先決定指令的型態和 opcode 為何:
t := mnemonic2type[inst[0]]
op := mnemonic2opcode[inst[0]]
之後當然就是要藉著不同的型別資訊,分別從傳入的指令字串元素裡面萃取出我們需要的部份。
要支援 R-type 的指令的話,我們也需要一份暫存器到 5-bit 整數的對照表。因此:
var reg2bits = map[string]uint32{
"x0": 0x00,
"zero": 0x00,
"x1": 0x01,
"ra": 0x01,
"x2": 0x02,
"sp": 0x02,
...
}
我們設定這個 reg2bits
對照表來解決我們的需求,所以就可以取得所有的暫存器編碼:
switch t {
case RV_INST_R_TYPE:
rd := reg2bits[inst[1]]
rs1 := reg2bits[inst[2]]
rs2 := reg2bits[inst[3]]
f3 := funct3[inst[1]]
最後一行的 funct3
用一樣的方法,存入邏輯運算指令的對應值,這裡就略過不標出來了。然而這樣就夠了嗎?R-type 還有最後一個區域:funct7
,但是因為只有 sub 和 sra 指令有非零值,因此只要加入特殊判斷就可以了:
var f7 uint32
if inst[0] == "sub" || inst[0] == "sra" {
f7 = 0x20
}
bits = f7<<25 | rs2<<20 | rs1<<15 | f3<<12 | rd<<7 | uint32(op)
case RV_INST_I_TYPE:
...
}
ret := make([]byte, 4)
binary.LittleEndian.PutUint32(ret, bits)
return ret
立刻使用以下順序試用看看:
$ make && cp go-binutils /tmp/as && /tmp/as -o add.o add.s
$ riscv64-unknown-linux-gnu-objdump -d add.o
/root/add.o: file format elf64-littleriscv
Disassembly of section .text:
0000000000000000 <add>:
0: 00b50533 add a0,a0,a1
4: 0000 unimp
...
成功!但是顯然現在的 InstToBin
還沒有強大到可以辨認 ret
這個虛擬指令,因此 GNU 的 objdump 工具程式會讓它成為未定義 unimp。
今日我們重新回到編輯器與終端機,回頭補強 go-binutils 專案,透過直接動手轉換指令內容與實際編碼,是不是更理解 RISC-V 的指令風格了呢?明日我們完成全部的基本整數指令之後,是時候再規劃一下接下來的方向了。各位讀者明日再會!